Skip to content

Add row-level geo distance check and fix documentation examples - #1510

Open
simplegaurav wants to merge 5 commits into
databrickslabs:mainfrom
simplegaurav:feature/maritime-geofencing
Open

Add row-level geo distance check and fix documentation examples#1510
simplegaurav wants to merge 5 commits into
databrickslabs:mainfrom
simplegaurav:feature/maritime-geofencing

Conversation

@simplegaurav

@simplegaurav simplegaurav commented Sep 5, 2026

Copy link
Copy Markdown

Changes

Adds is_geo_within_distance, a row-level geospatial check that reports point values farther than a
maximum geodesic distance, in meters, from a reference point.

- criticality: error
  check:
    function: is_geo_within_distance
    arguments:
      column: location
      reference_geometry: "POINT(4.90 52.37)"
      distance: 1000
      convert_column: true
      convert_reference_geometry: true

Design — every point below was verified against a live serverless workspace, not assumed

  • Distance via st_distancespheroid, i.e. meters on the WGS 84 ellipsoid. The planar st_distance
    returns coordinate units (degrees for longitude/latitude data — 0.15 for two points 10 km apart),
    and on this runtime none of st_distance, st_distancesphere or st_distancespheroid accept
    GEOGRAPHY. The check therefore operates on GEOMETRY parsed with try_to_geometry (WKT, WKB,
    EWKT, EWKB, GeoJSON), exactly like the other is_geo_* checks.
  • Points only. st_distancespheroid raises ST_INVALID_ARGUMENT at runtime for any non-point
    argument, so the call is gated behind a when on st_geometrytype; a polygon row is reported as
    is not a point geometry instead of crashing the job.
  • SRID handling. try_to_geometry assigns SRID 0 to WKT/WKB and 4326 to EWKT/GeoJSON, and
    st_distancespheroid raises ST_DIFFERENT_SRID_VALUES on a mismatch. Both operands are stamped
    4326 before measuring (SRID 0 is treated as WGS 84, 4326 is used as is, so formats can be mixed);
    any other SRID is reported per row rather than silently misread as degrees.
  • Distinct messages for an unparseable column value (raw input, rendered as hex for binary WKB —
    a raw string cast of WKB is invalid UTF-8 and crashes result collection), an unparseable reference,
    a non-point column value or reference, a projected SRID on either, and an empty reference (which
    would otherwise silently disable the check). Null column values, null reference columns, empty
    column points and null distances are skipped, matching the null semantics of the sibling checks.
  • distance accepts a number, a Column, or a SQL expression evaluated per row. Numeric
    literals and numeric strings are validated up front: negative, NaN, infinite, boolean, and
    float-overflowing values raise InvalidParameterError.
  • convert_column / convert_reference_geometry default to False, matching every sibling check.
    Operand preparation is shared with _has_topological_relationship_precise through a new
    _prepare_geo_operands helper, and the per-operand point/SRID diagnostics live in
    _diagnose_point_operand.

Also in this PR

  • Fixes the pre-existing programmatic examples for seven is_geo_* checks in the reference docs,
    which used DQDatasetRule although all of them are registered as row rules and raised
    InvalidCheckError as written.
  • Registers the check in the Studio built-in rule severity seed at Low, alongside its siblings.

Branch: 4 commits, 9 files, +650/−15. Both review rounds from @ghanse and @mwojtyczka are addressed
in eeb4f887; see the inline replies for the per-comment details.

Linked issues

None.

Tests

  • manually tested
  • added unit tests
  • added integration tests
  • added end-to-end tests
  • added performance tests

Run against a live serverless workspace:

  • tests/integration/test_row_checks_geo.py — the full file, 75 passed, including 20 tests for
    this check: inside/outside radius, per-row and null radius, SQL-expression and numeric-string
    radius, per-row and null reference, unparseable column and reference, polygon column and reference,
    valid and invalid binary WKB, WKT mixed with GeoJSON in both directions, projected-SRID column and
    reference, empty column point and empty reference, and native GEOMETRY input with
    convert_column=False.
  • test_apply_checks_all_geo_checks_using_classes and test_apply_checks_all_geo_checks_as_yaml,
    with the check added to all_row_geo_checks.yaml — both pass.
  • Studio: make app-test K=builtin_rules_seed — 23 passed.
  • Unit: 54 tests in tests/unit/test_geo_check_funcs.py plus the parameter-order contract in
    test_check_func_signatures.py; full unit suite green.
  • Perf: test_benchmark_is_geo_within_distance alongside the is_geo_covers benchmarks. Not runnable
    locally on Windows (the perf fixture's 1900-01-01 start date fails in datetime.timestamp() there,
    for every benchmark); CI runs it on Linux.

Local gates: black, ruff, mypy ., and pylint src tests (10.00/10) all clean.

Documentation and Demos

  • added/updated demos
  • added/updated docs
  • added/updated agent skills

docs/dqx/docs/reference/quality_checks.mdx: row-level table entry, YAML example and DQRowRule
example, plus the DQDatasetRuleDQRowRule fix described above.

@simplegaurav
simplegaurav requested a review from a team as a code owner September 5, 2026 22:00
@simplegaurav
simplegaurav requested review from nehamilak-db and removed request for a team September 5, 2026 22:00
@CLAassistant

CLAassistant commented Sep 5, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

All commits in PR should be signed ('git commit -S ...'). See https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits

simplegaurav and others added 2 commits September 6, 2026 03:45
Adds a row-level geo check that flags values farther than a maximum
geodesic distance from a reference geography. Distance is measured in
meters along the WGS 84 ellipsoid via `st_distance` on GEOGRAPHY values,
so the check is meaningful for global data where planar GEOMETRY
distances are not.

The reference accepts a literal WKT/WKB value or a Column expression, and
the maximum distance accepts a number, a Column, or a SQL expression so
the radius can vary per row. Null column values and null distances are
skipped; unparseable column and reference values are reported separately
so the message names the value that has to be fixed.

Numeric distance literals are validated up front: negative, NaN, infinite
and boolean values are rejected with InvalidParameterError.

The convert_column / convert_reference_geometry flags default to False,
matching the existing is_geo_* relationship checks, and the rendering of
the offending value follows that contract - the raw value when the input
is converted from WKT/WKB, st_astext when the column is already a native
GEOGRAPHY.

Covered by unit tests, integration tests, the all-row-geo metadata
fixture, the programmatic class-based integration test, a performance
benchmark, and the quality checks reference documentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The programmatic examples for is_geo_contains, is_geo_covers,
is_geo_intersects, is_geo_touches and is_geo_within construct the rules
with DQDatasetRule, but all of these checks are registered with
@register_rule("row"). Copying the snippets as written fails:

    InvalidCheckError: Function 'is_geo_within' is not a dataset-level
    rule. Use DQRowRule instead.

Switch the seven affected examples to DQRowRule and add the missing
import. are_polygons_mutually_disjoint is a genuine dataset-level rule
and is left unchanged.

These examples are not exercised by
test_apply_checks_all_geo_checks_using_classes, which is why the error
went unnoticed; backfilling that coverage needs a workspace to pick
reference geometries that pass, and is left as a follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good contribution. Left a few minor comments. Need to add 1 test case.

Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated
Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated
Comment thread tests/integration/test_row_checks_geo.py
@ghanse ghanse added the under-review This PR is currently being reviewed by one of DQX maintainers. label Sep 6, 2026
Include GeoJSON in the documented input formats. The docstring listed the
try_to_geometry formats (WKT, WKB, EWKT, EWKB) copied from the sibling
relationship checks, but this check parses with try_to_geography, which
also accepts GeoJSON. Updated the docstring, the reference_geometry
argument description and the reference documentation table.

Add integration coverage for convert_column=False. Both new tests build a
native GEOGRAPHY column with try_to_geography and then leave the convert
flags at their defaults, covering the pass and violation paths and
exercising the st_astext rendering branch used when the column is already
a GEOGRAPHY value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@simplegaurav

Copy link
Copy Markdown
Author

Thanks for the review — all three addressed in 3ce2ddd6.

GeoJSON in supported formats

Good catch. The docstring listed the try_to_geometry formats (WKT, WKB, EWKT, EWKB) that I'd
copied from the sibling relationship checks, but this check parses with try_to_geography, which
also accepts GeoJSON. Updated in three places: the docstring, the reference_geometry argument
description, and the row-level checks table in quality_checks.mdx.

Missing convert_column=False test

Added two integration tests covering the native GEOGRAPHY path — one pass case, one violation:

def test_is_geo_within_distance_native_geography_violation(skip_if_runtime_not_geo_compatible, spark):
    """A native GEOGRAPHY value outside the radius is flagged, with the value rendered via st_astext."""
    point = "POINT(5.05 52.37)"
    test_df = spark.createDataFrame([[point]], _GEO_SCHEMA).select(
        F.call_function("try_to_geography", F.col("geom")).alias("geom")
    )
    condition = is_geo_within_distance("geom", F.call_function("try_to_geography", F.lit(_POINT_INSIDE)), 1000)
    ...

They build the column with try_to_geography in a select and then leave both convert flags at
their defaults, so the check receives a genuinely GEOGRAPHY-typed column. The violation case also
exercises the st_astext rendering branch, which is only reachable on that path — with
convert_column=True the message renders the raw input instead, since st_astext returns NULL for
exactly the unparseable values the invalid-geography message is about.

I assert on the condition column alone rather than also selecting geom, since a GEOGRAPHY-typed
column can't be compared against a string schema.

One thing I noticed while writing those

No test in the repo currently builds a native GEOGRAPHY column. Every is_geo_* check defaults to
convert_column=False, but all existing tests pass True, so the default path is untested across
the geo module rather than just here. Might be worth a companion issue to #1513 — happy to open one
if useful.

Thanks for opening #1513.

@simplegaurav
simplegaurav requested a review from ghanse September 6, 2026 18:05

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few minor suggestions.

Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated
Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated

@mwojtyczka mwojtyczka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code review for is_geo_within_distance — 5 findings inline, ranked most-severe first (WKB rendering and the string-distance validation gap are the two worth acting on; the rest are lower severity). Line numbers verified against the PR head.

Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated
Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated
Comment thread src/databricks/labs/dqx/geo/check_funcs.py
Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated
Comment thread src/databricks/labs/dqx/geo/check_funcs.py Outdated

@mwojtyczka mwojtyczka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR, I left some comments

@mwojtyczka mwojtyczka added the needs-changes Changes required after review label Sep 10, 2026
…ess review

Verified against a live serverless workspace, the original design does not
work: st_distance returns planar coordinate units (degrees for lon/lat data)
rather than meters, and st_distance, st_distancesphere and st_distancespheroid
all reject GEOGRAPHY arguments. Rebuild the check on st_distancespheroid over
GEOMETRY parsed with try_to_geometry (WKT, WKB, EWKT, EWKB, GeoJSON), which
returns meters on the WGS 84 ellipsoid and matches how the sibling is_geo_*
checks parse their input.

st_distancespheroid is defined for points only and raises on any other
geometry type and on mismatched SRIDs, so guard both:

- Gate the call behind `when` on st_geometrytype, and report a non-point
  column value or reference instead of failing the job.
- Stamp both operands with SRID 4326 before measuring. try_to_geometry
  assigns SRID 0 to WKT/WKB and 4326 to EWKT/GeoJSON, so mixed formats would
  otherwise raise ST_DIFFERENT_SRID_VALUES. SRID 0 is treated as WGS 84 and
  4326 used as is; any other SRID is reported rather than silently misread as
  degrees.

Review feedback addressed in the same change:

- Include GeoJSON in the documented input formats (ghanse).
- Add integration coverage for convert_column=False (ghanse).
- Render a too-far value via st_astext so WKB input reads as WKT, and render
  an unparseable value from the raw input with binary shown as hex; casting
  raw WKB bytes to string is not valid UTF-8 and broke result collection
  (ghanse, mwojtyczka).
- Catch OverflowError from math.isfinite for very large distances (ghanse).
- Validate numeric-string distances with the same rule as numeric literals,
  so "-100" and -100 behave alike (mwojtyczka).
- Share operand preparation with _has_topological_relationship_precise via
  _prepare_geo_operands (mwojtyczka).

Further hardening from an adversarial review of the redesign:

- A null reference column value leaves the row unmeasurable and skipped,
  matching the null semantics of the other geo checks, instead of being
  reported as an invalid geometry.
- An empty reference point is reported on every row, since it silently
  disabled the check.
- Docstrings use italics rather than backticks for object names.

Register the check in the Studio built-in rule severity seed at Low alongside
its siblings, and extend the integration tests to cover every behaviour above:
per-row and null radius, SQL-expression and numeric-string radius, per-row and
null reference, unparseable column and reference, polygon column and
reference, valid and invalid binary WKB, WKT mixed with GeoJSON in both
directions, projected-SRID column and reference, empty point and empty
reference, and native GEOMETRY input.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@simplegaurav

Copy link
Copy Markdown
Author

Pushed eeb4f887, which addresses the second round of comments and, more importantly, reworks the core of the check after verifying it against a live serverless workspace.

  • The original design measured with st_distance on GEOGRAPHY. On this runtime st_distance, st_distancesphere and st_distancespheroid all reject GEOGRAPHY (DATATYPE_MISMATCH), and st_distance on GEOMETRY is planar — it returned 0.15 for two lon/lat points 10 km apart, i.e. degrees. The check now parses with try_to_geometry like the other is_geo_* checks and measures with st_distancespheroid, which returns meters on the WGS 84 ellipsoid (10,216.6 m / 68.1 m for the test points).
  • st_distancespheroid is points-only and raises at runtime on any other geometry type, and on mismatched SRIDs (try_to_geometry assigns SRID 0 to WKT/WKB but 4326 to EWKT/GeoJSON). Both are guarded: a non-point column value or reference is reported instead of raising, and both operands are stamped SRID 4326 before measuring — 0 is treated as WGS 84, 4326 used as is, and any other SRID is reported rather than silently misread as degrees.
  • An empty reference is reported on every row (it silently disabled the check), and a null reference column now leaves the row unmeasurable and skipped, matching the null semantics of the sibling checks.

Every behaviour above has an integration test that passes on the live workspace: the full test_row_checks_geo.py (75 tests, 20 for this check), the class-based and YAML all-geo tests, and the Studio seed tests.

@simplegaurav

Copy link
Copy Markdown
Author

@ghanse thanks for the two rounds of review — all five of your comments are addressed in eeb4f887, with a reply on each thread:

  • GeoJSON added to the documented formats (and the SRID mismatch that mixing it with WKT would have caused is handled).
  • convert_column=False now has integration coverage on the native GEOMETRY path.
  • Too-far values render via st_astext, so WKB shows as WKT; unparseable binary input renders as hex.
  • math.isfinite overflow is caught and raises InvalidParameterError.

One thing worth your attention beyond the comments: verifying against a live serverless workspace (DBR 19.6, so not a runtime-age issue) showed the original st_distance-on-GEOGRAPHY design couldn't work — the runtime's own DESCRIBE FUNCTION defines st_distance as 2D Cartesian over GEOMETRY, and no Databricks distance function accepts GEOGRAPHY. The check is now built on st_distancespheroid with point-type and SRID guards; every function it uses is documented for DBR 17.1+, so the declared minimum still holds. The PR description has the details, and the full geo integration file passes live (75 tests, 20 for this check).

Could you take another look when you have a moment?

@simplegaurav
simplegaurav requested a review from ghanse September 12, 2026 11:20
@simplegaurav

Copy link
Copy Markdown
Author

@mwojtyczka thanks for the review — all five findings are addressed in eeb4f887, with a reply on each thread:

  • Binary WKB rendering: unparseable binary now renders as hex; parsed values go through st_astext. On the live workspace the raw cast was worse than mojibake — it crashed result collection — so this was a real fix, not cosmetic.
  • String distance validation: numeric strings are now held to the same rule as numeric literals, so "-100" and -100 behave alike.
  • Operand-prep duplication: extracted into _prepare_geo_operands, shared with _has_topological_relationship_precise.
  • F.expr / is_sql_query_safe: left as is, with evidence on the thread — F.expr can't execute statements, and the one thing it can do (scalar subqueries) is_sql_query_safe permits anyway. I've proposed a subquery guard in get_limit_expr as a follow-up so all limit-taking checks benefit consistently.
  • Duplicated subexpressions: left as is; reasoning on the thread.

The check itself was also reworked after live verification showed the st_distance/GEOGRAPHY design didn't run on serverless — it's now st_distancespheroid over GEOMETRY with point-type and SRID guards, all covered by integration tests that pass live. Details in the updated PR description.

Would you be able to re-review?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-changes Changes required after review under-review This PR is currently being reviewed by one of DQX maintainers.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants